/**
 * FILE:  RecordEditingUI.js
 *
 * Part of the Metavus digital collections platform
 * Copyright 2019-2022 Edward Almasy and Internet Scout Research Group
 * http://metavus.net
 *
 * JavaScript support for RecordEditingUI
 */

$(document).ready(function(){
    var selectionsToAdd = [],
        selectionsToRemove = [],
        termsToCreate = [],
        timer = null,
        itemsPerPage = 50,
        curIndex = 0,
        lastIndex = 0;

    /**
     * Function to handle ajax errors.
     * @param jqXHR jqXHR jQuery encapsulation of XmlHttpRequest that
     *   generated an error (see https://api.jquery.com/Types/#jqXHR)
     * @param string textStatus Text description of what went wrong, one of
     *   "timout", "error", "abort', and "parsererror".
     * @param string errorThrown Text of the HTTP error response.
     * @see https://api.jquery.com/jquery.ajax/
     */
    function ajaxErrorHandler(jqXHR, textStatus, errorThrown) {
        console.log(
            "Error sending AJAX request at " +
                (new Error()).stack +
                textStatus + " " + errorThrown + " " + jqXHR.responseText);
    }

    /**
     * Append new checkbox for vocabulary item in alphabetical order.
     * @param HtmlElement target Element to which we're appending a term.
     * @param HtmlElement element HTML representing the term to append
     *   as generated by createInputRow()
     */
    function appendInOrder(target, element) {
        var rows = $("div", target);

        // if there is nothing in the target, just add this thing to it
        if (rows.length == 0) {
            $(target).append(element);
            return;
        }

        // otherwise, iterate over the elements in the target,
        // looking for the first element that this one should not come before
        // if we find one, prepend this element to it
        var newTerm = $("label", element).text(),
            curRow = null;

        for (var index = 0; index < rows.length; index++) {
            curRow = rows.get(index);

            var curTerm = $("label", curRow).text();
            // if the new term should be before the current term,
            // then put it there and exit
            if (newTerm.localeCompare(curTerm) < 0) {
                element.insertBefore(curRow);
                return;
            }
        }

        // othrewise, add this at the end
        element.insertAfter(curRow);
    }

    /**
     * Generate HTML elements to represent a new vocabulary term.
     * @param Object term Object having attributes tid (int, giving
     *   the TermID), and name (string, giving the display name of the
     *   term)
     * @return HtmlElement Newly created DOM elements for this term.
     */
    function createInputRow(term) {
        var fieldId = $("#mv-vocabsearchpopup").data('fieldid'),
            formFieldName = "F_" + fieldId + "_" + term.tid,
            element = $(
                "<div id='" + formFieldName + "_Row'>" +
                "<input type='checkbox' id='" + formFieldName + "' value='" + term.tid + "'>" +
                    "&nbsp;<label class='mv-term' for='" + formFieldName + "'>" +
                    term.name + "</label></div>"
            );
        $("input", element).change(handleSelection);

        return element;
    }

    /**
     * Adjust the width of the popup to make horizontal space for the
     * elements matched by a given selector.
     * @param string selector jQuery selector specifying elements to
     *   accommodate.
     */
    function adjustPopupWidth(selector) {
        // (all calculations in px because that is what jquery-ui's
        //  dialog requires)
        const marginWidth = 75;

        // get the current width of the popup
        var width = $("#mv-vocabsearchpopup").dialog("option", "width");

        // set popup to be as wide as the viewport so that scrollWidth
        // won't be wrapped to the container and can be used to
        // determine how much space an element requires. since modern
        // browsers all buffer DOM updates till the end of JS function
        // calls, this new width won't ever actually be displayed to
        // the user, just used for calculating space requirements
        $("#mv-vocabsearchpopup").dialog("option", "width", $(window).width());

        // iterate over all our available terms to determin the widest
        $(selector).each(function(index, element) {
            width = Math.max(element.getBoundingClientRect().width + marginWidth, width);
        });

        // set the popup to the calculated width
        $("#mv-vocabsearchpopup").dialog("option", "width", width);
    }

    /**
     * React to selection/deselection of vocabulary terms. This
     * handler pushes the term that was clicked on to a selectionsToXX
     * list and then fires an AJAX request. The actual DOM updates are
     * all handled once we have updated vocabulary terms to display so
     * that we can do all the updates in one shot.
     * @param Event event JS event that triggered this update.
     */
    function handleSelection(event) {
        if ($(this).is(":checked")) {
            selectionsToAdd.push($(this).parent());
        } else {
            selectionsToRemove.push($(this).parent());
        }

        ajaxUpdate();
    }

    /**
     * Append the pending, temporary terms that we've queued for
     * addition to the existing terms that match our search.
     * @param data provided by VocabularySearchCallback (modified in place)
     */
    function addPendingTerms(data){
        var fieldId = $("#mv-vocabsearchpopup").data('fieldid');

        // add pending terms that match the search
        var searchString = $("#mv-vocabsearchpopup .searchString").val()
            .trim().replace(/\s+/, ' ').toLowerCase().replace(/[^a-z0-9]/, '')
        termsToCreate.forEach(function(term) {
            // create a normalized term name
            var normTerm = term.name.toLowerCase().replace(/[^a-z0-9]/, '');

            // if this term matches the current search
            if (searchString.length < 3 || normTerm.indexOf(searchString) != -1) {
                // see if we have an element for it
                var curEl = $("#mv-vocabsearchpopup .TermsAssigned " +
                              "div#F_" + fieldId + "_" + term.tid + "_Row");

                // if not, add this term in to the results from the search
                if (curEl.length == 0) {
                    data.terms.push(term);
                    data.count++;
                }
            }
        });

        // sort results to put them in alphabetical order
        data.terms.sort(function(a,b) {
            return a.name.localeCompare(b.name);
        });

        // if we're not at the beginning of the results and if the
        // first element shown is a pending term, don't display it
        while (curIndex != 0 && data.terms[0].tid < 0) {
            data.terms.shift();
        }

        // if we now have more items than we should, remove the extras
        while (data.terms.length > itemsPerPage) {
            data.terms.pop();
        }
    }

    /**
     * Update displayed pagination after receiveng new terms from a search.
     * @param data provided by VocabularySearchCallback
     */
    function updatePagination(data) {
        // show a 'no terms avaiable' message if needed
        var numShown = $("#mv-vocabsearchpopup .TermsAvailable div").length;
        if (numShown == 0) {
            $("#mv-vocabsearchpopup .mv-search-info").hide();
            $("#mv-vocabsearchpopup .TermsAvailable").append(
                $("<div><i>(no matching unselected terms)</i></div>")
            );
        } else {
            $("#mv-vocabsearchpopup .mv-search-info").show();
        }

        // show a 'no terms selected' message if needed
        var numSelected = $("#mv-vocabsearchpopup .TermsAssigned div:not(.mv-empty)").length;
        if (numSelected == 0) {
            if ($("#mv-vocabsearchpopup .TermsAssigned div.mv-empty").length == 0) {
                $("#mv-vocabsearchpopup .TermsAssigned").append(
                    $("<div class='mv-empty'><i>(no terms selected)</i></div>")
                );
            }
        } else {
            $("#mv-vocabsearchpopup .TermsAssigned div.mv-empty").remove();
        }

        // compute the last index
        var overflowItems = data.count % itemsPerPage,
            itemsOnLastPage = (overflowItems == 0) ? itemsPerPage : overflowItems;
        lastIndex = Math.max(0, data.count - itemsOnLastPage);

        // update numbers displayed
        $("#mv-vocabsearchpopup .resultCount").text(data.count);
        $("#mv-vocabsearchpopup .startIndex").text(curIndex + 1);
        $("#mv-vocabsearchpopup .endIndex").text(curIndex + numShown);

        // toggle visibility on pagination buttons
        if (data.count <= itemsPerPage) {
            $("#mv-vocabsearchpopup .mv-search-pagination").hide();
        } else {
            $("#mv-vocabsearchpopup .mv-search-pagination").show();

            // disable buttons that would take us outside our search results
            $("#mv-vocabsearchpopup .mv-search-pagination button").show();
            $("#mv-vocabsearchpopup .mv-search-pagination .mv-btn-page").each(function(index, element){
                var increment = itemsPerPage * parseInt($(element).data('pages')),
                    target = curIndex + increment;
                if (target <= 0 || target >= lastIndex) {
                    $(element).hide();
                }
            });

            if (curIndex == 0) {
                $("#mv-vocabsearchpopup .mv-btn-start").hide();
            }

            if (curIndex >= lastIndex) {
                $("#mv-vocabsearchpopup .mv-btn-end").hide();
            }
        }
    }

    /**
     * Update UI elements with new vocabulary terms after an AJAX callback fetches them.
     * @param Object data JSON returned by the AJAX callback.
     *   Expected format of the data coming from VocabularySearchCallback.php is:
     *     [
     *        "count" => (int),
     *        "terms" => [ ["tid" => (int)ItemId, "name" => (string)ItemName], ... ]
     *     ]
     */
    function update(data){
        var fieldId = $("#mv-vocabsearchpopup").data('fieldid');

        curIndex = $("#mv-vocabsearchpopup .startIndex").data('index');

        // handle queued addition/removal of checkboxes because of
        // (de)selection of elements
        var element;
        while (element = selectionsToRemove.pop()) {
            element.remove();
        }
        while (element = selectionsToAdd.pop()) {
            appendInOrder("#mv-vocabsearchpopup .TermsAssigned", element.detach());
        }

        // add in our pending terms
        addPendingTerms(data);

        // mark all current terms unused
        $("#mv-vocabsearchpopup .TermsAvailable div").attr('data-unused', true);

        // iterate over desired terms
        data.terms.forEach(function(term){
            // look for a matching element
            var curEl = $("#mv-vocabsearchpopup .TermsAvailable " +
                          "div#F_" + fieldId + "_" + term.tid + "_Row");

            // if UI elements were present for this term
            if (curEl.length > 0) {
                // remove the 'unused' flag
                curEl.removeAttr('data-unused');
            } else {
                // if not, create them and add them to the UI
                appendInOrder("#mv-vocabsearchpopup .TermsAvailable", createInputRow(term));
            }
        });

        // remove unused terms
        $("#mv-vocabsearchpopup .TermsAvailable div[data-unused='true']").remove();

        // if this is a controlled name that allows adding, show the add button
        if ($("#mv-vocabsearchpopup").data('showaddbutton')) {
            $("#mv-vocabsearchpopup .mv-btn-add").show();
        } else {
            $("#mv-vocabsearchpopup .mv-btn-add").hide();
        }

        updatePagination(data);

        adjustPopupWidth("#mv-vocabsearchpopup div.TermsAvailable div label.mv-term");
    }

    /**
     * Get the list of selected terms that should be excluded from
     * vocabulary search results.
     * @return Array Array of int TermIDs
     */
    function getExclusions() {
        var tidsToRemove = [];
        selectionsToRemove.forEach(function(element){
            tidsToRemove.push( $("input", element).val() );
        });

        var exclusions = [];
        $("#mv-vocabsearchpopup .TermsAssigned input").each(function(index, element) {
            var tid = $(element).val();
            if (tid > 0) {
                if (!tidsToRemove.includes(tid)){
                    exclusions.push(tid);
                }
            }
        });
        selectionsToAdd.forEach(function(element){
            var tid = $("input", element).val();
            if (tid > 0) {
                exclusions.push(tid);
            }
        });

        return exclusions.sort();
    }

    /**
     * Set the selections in the editing interface and close the popup.
     * @param Array selections Array of {tid: (int), name: (string) } giving the
     *   selections that should be set.
     */
    function setSelectionsAndClosePopup(selections) {
        // signal the UI element that opened our popup to
        // update itself with the new selections
        $("#mv-vocabsearchpopup").data('target').triggerHandler(
            "mv:setselections", [ selections ]
        );

        // clear our list of terms that need to be created
        termsToCreate = [];

        // and close the popup
        window.clearTimeout(timer);
        $("#mv-vocabsearchpopup").dialog("close");
    }

    /**
     * Start an AJAX request that will fetch a list of vocabulary
     * terms to display.
     */
    function ajaxUpdate() {
        var searchString = $("#mv-vocabsearchpopup .searchString").val().trim().replace(/\s+/, ' '),
            startIndex = $("#mv-vocabsearchpopup .startIndex").data('index');
        if (searchString.length < 3) {
            searchString = '';
        }

        $("#mv-vocabsearchpopup .mv-loading").show();
        $("#mv-vocabsearchpopup .mv-btn-add").hide();
        $("#mv-vocabsearchpopup .mv-controls button").prop('disabled', true);

        $.ajax({
            url: "index.php?P=VocabularySearchCallback",
            data: {
                ID: $("#mv-vocabsearchpopup").data('fieldid'),
                SS: searchString,
                SI: Math.max(startIndex - termsToCreate.length, 0),
                N: itemsPerPage + termsToCreate.length,
                EX: getExclusions().join("-")
            },
            success: function(data) {
                $("#mv-vocabsearchpopup .mv-controls button").prop('disabled', false);
                update(data);
                $("#mv-vocabsearchpopup .mv-loading").hide();
                $("#mv-vocabsearchpopup .mv-controls").show();
            },
            error: ajaxErrorHandler
        });
    }

    // configure the vocabulary search popup
    // see https://api.jqueryui.com/dialog/
    $("#mv-vocabsearchpopup").dialog({
        modal: true,
        autoOpen: false,
        position: { my: "top", at: "top+20", of: window },
        buttons: {
            Apply: function() {
                // if we have no terms to add, then we can just set
                // our selections and be done
                if (termsToCreate.length  ==  0) {
                    // get the list of current selections
                    var selections = [];
                    $("#mv-vocabsearchpopup .TermsAssigned div").each(function(index, element) {
                        var tid = $("input", element).val();
                        selections.push({
                            tid: tid,
                            name: $("label", element).text()
                        });
                    });

                    setSelectionsAndClosePopup(selections);
                } else {
                    // otherwise, we need to add our terms with an ajax call
                    $.ajax({
                        url: "index.php?P=AddControlledNamesCallback",
                        data: {
                            ID: $("#mv-vocabsearchpopup").data('fieldid'),
                            Terms: termsToCreate
                        },
                        method: "POST",
                        success: function(data) {
                            // log errors
                            if (data.status == "Error") {
                                console.log(data.message);
                                return;
                            }

                            // get the list of current selections, filling in real tids
                            // for any newly created terms
                            var selections = [];
                            $("#mv-vocabsearchpopup .TermsAssigned div").each(function(index, element) {
                                var tid = $("input", element).val();
                                if (tid < 0) {
                                    tid = data.termsCreated[tid];
                                }
                                selections.push({
                                    tid: tid,
                                    name: $("label", element).text()
                                });
                            });

                            setSelectionsAndClosePopup(selections);
                        },
                        error: ajaxErrorHandler
                    });
                }
            },
            Cancel: function() {
                selectionsToAdd = [];
                selectionsToRemove = [];
                termsToCreate = [];
                window.clearTimeout(timer);
                $("#mv-vocabsearchpopup").dialog("close");
            }
        },
        open: function(event, ui) {
            // set the title
            $("#mv-vocabsearchpopup").dialog(
                "option",
                "title",
                "Search Vocabulary for " + $("#mv-vocabsearchpopup").data('fieldname')
            );

            itemsPerPage = $.cookie('vocabSearchPopupItems_' + $("#mv-vocabsearchpopup").data('fieldid'));
            if (itemsPerPage == null) {
                itemsPerPage = 50;
            } else {
                itemsPerPage = parseInt(itemsPerPage);
                if (isNaN(itemsPerPage)) {
                    itemsPerPage = 50;
                }
            }

            $("#mv-vocabsearchpopup .mv-num-results-per-page input").val(itemsPerPage);

            // reset the pagination
            $("#mv-vocabsearchpopup .startIndex").data('index', 0);

            // erase any old selections in the popup
            $("#mv-vocabsearchpopup .TermsAssigned div").remove();

            // erase previous available terms
            $("#mv-vocabsearchpopup .TermsAvailable div").remove();

            // clear search string
            $("#mv-vocabsearchpopup .searchString").val('');

            // get current selections from UI element
            var selections = $("#mv-vocabsearchpopup").data('target')
                .triggerHandler("mv:getselections");

            // create form elements for them and check them
            selections.forEach(function(term) {
                appendInOrder("#mv-vocabsearchpopup .TermsAssigned", createInputRow(term));
            });
            $("#mv-vocabsearchpopup .TermsAssigned input").prop('checked', true);

            // set a minimum width of 500px
            $("#mv-vocabsearchpopup").dialog("option", "width", 500);

            // and then widen the popup if needed
            adjustPopupWidth("#mv-vocabsearchpopup div.TermsAssigned div label");

            // search for available terms
            ajaxUpdate();
        }
    });

    // when new text is entered into the search text, search for new
    // matching terms
    $("#mv-vocabsearchpopup .searchString").on('keyup',function(){
        window.clearTimeout(timer);
        timer = window.setTimeout(function(){
            $(".startIndex", "#mv-vocabsearchpopup").data('index', 0);
            ajaxUpdate();
        }, 750);
    });

    // reload when itemsPerPage is changed
    $("#mv-vocabsearchpopup .mv-num-results-per-page input").change(function(){
        itemsPerPage = $(this).val();
        $.cookie(
            'vocabSearchPopupItems_' + $("#mv-vocabsearchpopup").data('fieldid'),
            itemsPerPage
        );
        ajaxUpdate();
    });

    // adjust the pagination by a provided increment
    function adjustPagination(increment) {
        $("#mv-vocabsearchpopup .startIndex").data(
            'index',
            Math.max(0, Math.min(lastIndex, curIndex + increment))
        );
        ajaxUpdate();
    }

    // handle clicks on pagination buttons
    $("#mv-vocabsearchpopup .mv-btn-page").click(function(){
        adjustPagination( itemsPerPage * parseInt($(this).data('pages')));
    });

    // handle clicks on 'first' button
    $("#mv-vocabsearchpopup .mv-btn-start").click(function(){
        $("#mv-vocabsearchpopup .startIndex").data('index', 0);
        ajaxUpdate();
    });

    // handle clicks on 'last' button
    $("#mv-vocabsearchpopup .mv-btn-end").click(function(){
        $("#mv-vocabsearchpopup .startIndex").data('index', lastIndex);
        ajaxUpdate();
    });

    // handle clicks on the 'add' button
    $("#mv-vocabsearchpopup .mv-btn-add").click(function(){
        if ($("#mv-vocabsearchpopup").data('fieldtype') != "ControlledName") {
            console.log("Add from popup is only supported for Controlled Name fields.");
            return;
        }

        // get a normalized term name
        var termName = $("#mv-vocabsearchpopup .searchString").val()
            .trim().replace(/\s+/, ' '),
            lowerTermName = termName.toLowerCase(),
            duplicate = false;

        // make sure we're not creating a duplicate term
        $("#mv-vocabsearchpopup label.mv-term").each(function(index, element) {
            if ($(element).text().toLowerCase() == lowerTermName) {
                duplicate = true;
                return false;
            }
        });
        if (duplicate) {
            return;
        }

        // add term to our create list with a negative tid
        var newTerm = {
            tid:  -1 * ( termsToCreate.length + 1),
            name:  termName
        }
        termsToCreate.push(newTerm);

        // append it to our UI, making sure it is assigned
        appendInOrder("#mv-vocabsearchpopup .TermsAssigned", createInputRow(newTerm));
        $("#mv-vocabsearchpopup .TermsAssigned input").prop('checked', true);

        // clear search string and re-display
        $("#mv-vocabsearchpopup .searchString").val('');
        ajaxUpdate();
    });

    // link the popup to each 'Search' button provided by a vocabulary field
    $(".mv-reui-search").click(function(){
        $("#mv-vocabsearchpopup").data('fieldid', $(this).data('fieldid'));
        $("#mv-vocabsearchpopup").data('fieldname', $(this).data('fieldname'));
        $("#mv-vocabsearchpopup").data('fieldtype', $(this).data('fieldtype'));
        $("#mv-vocabsearchpopup").data('showaddbutton', $(this).data('showaddbutton'));
        $("#mv-vocabsearchpopup").data('target', $(".mv-mutable-widget", $(this).closest("td")));
        $("#mv-vocabsearchpopup").dialog("open");
    });

    // set up the 'Clear' button next to option list fields
    $(".mv-reui-clear").click(function(){
        $("input", $(this).parent().parent()).prop('checked', false);
    });

    // set up the 'Update' button next to user fields
    $(".mv-reui-update-user").click(function(){
        var container = $(this).parent().parent(),
            userId = $(this).data('userid'),
            selections = [],
            hasCurrentUser = false;

        // if multiples are allowed
        if ($(this).data('allowmultiple')) {
            // get current list of selections
            selections = $(".mv-quicksearchset", container).triggerHandler("mv:getselections");

            // and see if the current user is included
            selections.forEach(function(term){
                if (term.tid == userId) {
                    hasCurrentUser = true;
                }
            });
        }

        // if selections didn't have the current user, add them
        if (!hasCurrentUser) {
            selections.push({
                tid: userId,
                name: $(this).data('username')
            });
        }

        // update our selections
        $(".mv-quicksearchset", container)
            .triggerHandler("mv:setselections", [selections] );
    });

    // change Insert-L and Insert-R buttons to use most recently focused CKEditor instance
    var insertButtons = $('button').filter(function(btn) {
        return this.innerHTML === 'Insert-L' || this.innerHTML === 'Insert-R';
    });

    // change CKEDITOR for all Insert-L and Insert-R buttons to the one most recently focused
    CKEDITOR.on('instanceReady', function(evt) {
        var editor = evt.editor;
        // note: this regex must be in line with button creation in FormUI::displayImageField()
        // with the format "CKEDITOR.instances[...]" in the image insertion command
        const regexp = /(?<=CKEDITOR\.instances\[).+(?=\])/;
        editor.on('focus', function(e) {
            insertButtons.each(function(btn) {
                var newOnClick = $(this).attr('onclick').replace(regexp, `'${editor.name}'`);
                $(this).attr('onclick', newOnClick);
            });
        });
    });
});